| 1 | "use client"; |
| 2 | import { useEffect, useRef, useState } from "react"; |
| 3 | import { useParams } from "next/navigation"; |
| 4 | import Link from "next/link"; |
| 5 | import { api, downloadAuthed, EvidenceItem, Investigation, TimelineEntry } from "@/lib/api"; |
| 6 | |
| 7 | export default function InvestigationDetail() { |
| 8 | const { id } = useParams<{ id: string }>(); |
| 9 | const [inv, setInv] = useState<Investigation | null>(null); |
| 10 | const [timeline, setTimeline] = useState<TimelineEntry[]>([]); |
| 11 | const [evidence, setEvidence] = useState<EvidenceItem[]>([]); |
| 12 | const [report, setReport] = useState<any | null>(null); |
| 13 | const [reportId, setReportId] = useState<string | null>(null); |
| 14 | const [err, setErr] = useState<string | null>(null); |
| 15 | const fileRef = useRef<HTMLInputElement>(null); |
| 16 | |
| 17 | function loadEvidence() { api.evidenceList(id).then(setEvidence).catch(() => {}); } |
| 18 | |
| 19 | useEffect(() => { |
| 20 | Promise.all([api.investigation(id), api.timeline(id)]) |
| 21 | .then(([i, t]) => { setInv(i); setTimeline(t); }) |
| 22 | .catch((e) => setErr(e.status === 401 ? "Sign in to view this investigation." : String(e))); |
| 23 | loadEvidence(); |
| 24 | }, [id]); |
| 25 | |
| 26 | async function genReport() { |
| 27 | try { const r = await api.createReport(id); setReport(r.report); setReportId(r.report_id); } |
| 28 | catch { setErr("Report generation failed."); } |
| 29 | } |
| 30 | |
| 31 | async function uploadEvidence(file: File) { |
| 32 | try { await api.uploadEvidence(id, file); loadEvidence(); } |
| 33 | catch (e: any) { setErr(e.status === 403 ? "Your role cannot attach evidence." : "Evidence upload failed."); } |
| 34 | } |
| 35 | |
| 36 | if (err) return <div className="badge danger">{err}</div>; |
| 37 | if (!inv) return <div className="muted">Loading…</div>; |
| 38 | |
| 39 | return ( |
| 40 | <div> |
| 41 | <h1>{inv.title}</h1> |
| 42 | <div className="row"><span className="badge">{inv.status}</span><span className="badge">{inv.priority}</span></div> |
| 43 | <p className="muted">{inv.summary}</p> |
| 44 | |
| 45 | <div className="row" style={{ justifyContent: "space-between" }}> |
| 46 | <h2 style={{ margin: 0 }}>Sighting timeline</h2> |
| 47 | <button className="primary" onClick={genReport}>Generate report</button> |
| 48 | </div> |
| 49 | <div className="card" style={{ marginTop: 10 }}> |
| 50 | <table> |
| 51 | <thead><tr><th>Time</th><th>Plate</th><th>Camera</th><th>Dir</th><th>Conf</th></tr></thead> |
| 52 | <tbody> |
| 53 | {timeline.map((t, i) => ( |
| 54 | <tr key={i}> |
| 55 | <td>{new Date(t.read_ts).toLocaleString()}</td> |
| 56 | <td><Link href={`/vehicles/${t.plate_norm}`}>{t.plate_norm}</Link></td> |
| 57 | <td className="muted">{t.camera_id?.slice(0, 8)}</td> |
| 58 | <td>{t.direction || "-"}</td> |
| 59 | <td>{t.confidence != null ? (t.confidence * 100).toFixed(0) + "%" : "-"}</td> |
| 60 | </tr> |
| 61 | ))} |
| 62 | {!timeline.length && <tr><td colSpan={5} className="muted">No sightings attached.</td></tr>} |
| 63 | </tbody> |
| 64 | </table> |
| 65 | </div> |
| 66 | |
| 67 | <h2>Evidence</h2> |
| 68 | <div className="card"> |
| 69 | <div className="row" style={{ marginBottom: 10 }}> |
| 70 | <input ref={fileRef} type="file" hidden onChange={(e) => e.target.files?.[0] && uploadEvidence(e.target.files[0])} /> |
| 71 | <button onClick={() => fileRef.current?.click()}>+ Attach evidence file</button> |
| 72 | <span className="muted">SHA-256 hashed on upload; integrity verified on download.</span> |
| 73 | </div> |
| 74 | <table> |
| 75 | <thead><tr><th>File</th><th>Size</th><th>SHA-256</th><th></th></tr></thead> |
| 76 | <tbody> |
| 77 | {evidence.map((e) => ( |
| 78 | <tr key={e.id}> |
| 79 | <td><Link href={`/evidence/${e.id}`}>{e.filename}</Link></td> |
| 80 | <td className="muted">{(e.size_bytes / 1024).toFixed(1)} KB</td> |
| 81 | <td className="muted" style={{ fontFamily: "monospace", fontSize: 11 }}>{e.sha256.slice(0, 16)}…</td> |
| 82 | <td><a style={{ cursor: "pointer" }} onClick={() => downloadAuthed(`/evidence/${e.id}/download`, e.filename)}>download</a></td> |
| 83 | </tr> |
| 84 | ))} |
| 85 | {!evidence.length && <tr><td colSpan={4} className="muted">No evidence attached.</td></tr>} |
| 86 | </tbody> |
| 87 | </table> |
| 88 | </div> |
| 89 | |
| 90 | {report && reportId && ( |
| 91 | <> |
| 92 | <h2>Report</h2> |
| 93 | <div className="card"> |
| 94 | <p>{report.sections?.executive_summary}</p> |
| 95 | <strong>Key findings</strong> |
| 96 | <ul>{(report.sections?.key_findings || []).map((f: string, i: number) => <li key={i}>{f}</li>)}</ul> |
| 97 | <div className="row" style={{ marginTop: 8 }}> |
| 98 | <span className="muted">Download:</span> |
| 99 | {["markdown", "json", "csv", "pdf"].map((fmt) => ( |
| 100 | <a key={fmt} style={{ cursor: "pointer" }} |
| 101 | onClick={() => downloadAuthed(`/reports/${reportId}/download?format=${fmt}`, |
| 102 | `report.${fmt === "markdown" ? "md" : fmt}`)}>{fmt}</a> |
| 103 | ))} |
| 104 | </div> |
| 105 | </div> |
| 106 | </> |
| 107 | )} |
| 108 | </div> |
| 109 | ); |
| 110 | } |